今天第22天來補足JWT驗證的邏輯,順便將控制器內產生JWT的邏輯進行封裝到專門的模組
昨日文章在結尾時有提到,控制器內實作產生JWT的邏輯違反單一職責原則,居然知道違反甚麼原則,那麼問題就很清晰在於「職責」的定義,那麼如何對職責進行切割,首先來看MVC架構中控制器關注的重點,就是在於接收請求,並做相應邏輯處理,在來看現在的情境是開設API,用來核發和驗證JWT兩種需求,直接在控制器中撰寫邏輯能滿足需求
這時候反問一個問題,若有其他API端點一樣要用到JWT的使用,但只是修改部分自定義的PAYLOAD,這時候將整個程式碼複製貼上改相應邏輯,只會產生更多重複的程式碼段落,除此程式碼重複之外,基於某些因素要更改JWT的相關配置,就要去改N個控制器類別,這時的情況是本來只接收請求的控制器,多了另一個管理JWT邏輯的職責,為了改變這個問題,將JWT處理邏輯封裝到一個模組內,可以讓職責更清晰,也能避免重複的程式碼邏輯,帶來的負面影響
這是通用的工具,工具類就放在utils資料夾下,在下面新增一個 JwtUtil的類別(記得檢查有沒有安裝依賴項目)
// 添加 Component 注釋表示這個元件,會由IoC容器管理
@Component
public class JwtUtil {
@Value("${jwt.expire_time}")
private int expireTime;
@Value("${jwt.secret}")
private String secret;
}
實作產生 JWT Token 的方法,邏輯大致沒變化,透過方法參數傳入相應資料即可
/**
* 初始化 Token 方法
*
* @param Map<String, Object> claims JWT要附加的屬性
* @param String subject 識別主體值
*
* @return String
*/
public String generateToken(
Map<String, Object> payload,
String subject) {
Date expireDate = new Date(System.currentTimeMillis() + expireTime);
// 產生隨機 UUDI 當作JWT ID
String jwtIdentityId = UUID.randomUUID().toString();
JwtBuilder jwtBuilder = Jwts.builder();
if (payload != null) {
Claims useClaims = Jwts.claims(payload);
jwtBuilder.setClaims(useClaims);
}
return jwtBuilder.setSubject(subject)
.setId(jwtIdentityId)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(expireDate)
.signWith(generateKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 將 String 轉換成 Key物件
*
* @return io.jsonwebtoken.security.Keys
*/
private SecretKey generateKey() {
byte[] encodeKey = secret.getBytes();
// Secret 轉換成符合 SHA規範的值
return Keys.hmacShaKeyFor(encodeKey);
}
打開 APIController 調整昨日使用JWT的實作
// 建立屬性,用來注入JWT工具類
@Autowired
private JwtUtil jwtUtil;
// 調整authJwt實作
@GetMapping("/jwt/generate")
public Map<String, Object> authJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("language", "java");
String jwtToken = jwtUtil.generateToken(claims, "Java Programer");
Map<String, Object> response = new HashMap<>();
response.put("token", jwtToken);
return response;
}
打開Postman測試API取得Token,並將結果貼到 jwt.io 查看與修改後的資料相符
解析 Token 內容
/**
*
* @param String token 要解析的 Token
*
* @return Claims
*/
private Claims parseToken(String token) {
return Jwts.parserBuilder().setSigningKey(generateKey())
.build().parseClaimsJws(token).getBody();
}
封裝方法,套用Claims定義好Lambda函式取得特定欄位值
/**
* @return <T> T 實際回傳型別依套用的 resovler為主
*/
private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = parseToken(token);
return claimsResolver.apply(claims);
}
使用Claims內建函示 getExpiration 驗證憑證是否到期
/**
* 驗證 Tokem是否逾期
*
* @return Boolean
*/
public Boolean validateToken(String token) {
final Date expiration = getClaimFromToken(token, Claims::getExpiration);
return expiration.before(new Date()) == false;
}
為了方便講解,模組調整的部分用單元測試來實作,現在來安裝junit進行測試,
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
接著在tests資料夾下新增測試項目,測試類別就叫JwtUtilTest
// JwtUtilTest.java
@SpringBootTest
// 定義測試的環境設定套用 application-test.properties
@ActiveProfiles("test")
public class JwtUtilTest {
// 別忘了注入工具類
@Autowired
private JwtUtil jwtUtil;
@Test
void handleToken() {
// 配置文件
System.out.println("Run MyFirst Test");
assertTrue(false);
}
}
在 resource 目錄下新增測試環境配置 application-test.properties,變數名稱同 application.properties,套用值依開發者環境調整
執行 mvnw test -Dtest=JwtUtilTest#handleToken 試跑測試,有打印出文字表示
測試產生JWT字串並執行Token驗證的邏輯
@Test
void handleToken() {
// 配置文件
Map<String, Object> claims = new HashMap<>();
claims.put("created_by", "jtest");
// 測試是否為 String
String jwtToken = jwtUtil.generateToken(claims, "Java Programer");
assertTrue(jwtToken instanceof String);
// 測試 Token是否逾期或異常
Boolean tokenIsValidated = jwtUtil.validateToken(jwtToken);
assertTrue(tokenIsValidated);
// 測試 解析不符合 Token 格式的字串
assertThrows(MalformedJwtException.class, () -> {
jwtUtil.validateToken("不符合Token的情況");
});
}
執行 mvn test -Dtest=JwtUtilTest#tokenData 測試取得 Token 內容
@Test
void tokenData() {
Map<String, Object> claims = new HashMap<>();
claims.put("founder", "jtest");
String jwtToken = jwtUtil.generateToken(claims, "tokenData");
Claims payload = jwtUtil.parseToken(jwtToken);
// 驗證 founder 欄位資料
String founder = payload.get("founder", String.class);
assertEquals("jtest", founder);
// 驗證 sub 資料
String sub = payload.get("sub", String.class);
assertEquals("tokenData", sub);
}
前面範例用到的 -Dtest選項,是用來指定要執行的類別,添加#方法名稱則就是對特定類別的方法進行測試,現在對tests目錄下所有測試項目,執行 mvn test執行所有測試